You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
292 lines
13 KiB
292 lines
13 KiB
<template>
|
|
<div>
|
|
<!-- 面包屑 -->
|
|
<div class="flex items-center gap-2 mb-4 text-sm">
|
|
<button class="btn btn-ghost btn-xs" @click="router.push('/apps')">← 应用中心</button>
|
|
<span class="text-slate-300">/</span>
|
|
<span class="font-semibold text-slate-700">{{ appName }}</span>
|
|
</div>
|
|
|
|
<div v-if="loading" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
|
|
<span class="loading loading-spinner loading-sm"></span>
|
|
</div>
|
|
|
|
<div v-else-if="!product" class="bg-white rounded-xl border border-slate-100 py-16 text-center">
|
|
<p class="text-slate-500">找不到应用「{{ appName }}」</p>
|
|
<button class="btn btn-sm mt-4" @click="router.push('/apps')">返回应用中心</button>
|
|
</div>
|
|
|
|
<template v-else>
|
|
<!-- 应用头卡 -->
|
|
<div class="hero">
|
|
<div class="hero-logo">{{ (product.name || '?').charAt(0).toUpperCase() }}</div>
|
|
<div class="flex-1 min-w-[240px]">
|
|
<div class="hero-name">
|
|
{{ product.name }}
|
|
<span class="chip" :class="product.enabled ? 'chip-ok' : ''">{{ product.enabled ? '启用' : '停用' }}</span>
|
|
<span class="chip chip-mono">🐳 {{ product.image_name || '未设置镜像' }}</span>
|
|
</div>
|
|
<div class="text-xs text-slate-500 mt-1.5">{{ product.remark || '—' }}</div>
|
|
</div>
|
|
<div class="flex gap-2">
|
|
<button class="btn btn-sm" @click="openAppForm()">编辑应用</button>
|
|
<button class="btn btn-primary btn-sm text-white" @click="deployTab?.openNew()">+ 部署新环境</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- KPI -->
|
|
<div class="kpis">
|
|
<div class="kpi accent">
|
|
<div class="kpi-l">部署</div>
|
|
<div class="kpi-v">{{ deployments.length }}<small>个区域</small></div>
|
|
</div>
|
|
<div class="kpi ok">
|
|
<div class="kpi-l">已接入</div>
|
|
<div class="kpi-v">{{ okCount }}</div>
|
|
</div>
|
|
<div class="kpi" :class="pendingCount ? 'warn' : ''">
|
|
<div class="kpi-l">待部署</div>
|
|
<div class="kpi-v">{{ pendingCount }}</div>
|
|
</div>
|
|
<div class="kpi">
|
|
<div class="kpi-l">版本分布</div>
|
|
<div class="kpi-v" style="font-size:14px;padding-top:5px">{{ versionSpread }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tablist" role="tablist">
|
|
<button class="tab" role="tab" :aria-selected="tab === 'deps'" @click="tab = 'deps'">
|
|
部署环境 <span class="tab-n">{{ deployments.length }}</span>
|
|
</button>
|
|
<button class="tab" role="tab" :aria-selected="tab === 'modules'" @click="tab = 'modules'">业务功能配置</button>
|
|
</div>
|
|
|
|
<!-- 配置目标切换器:业务功能配置写「某个部署自己的业务库」 -->
|
|
<div v-if="tab === 'modules'" class="depbar">
|
|
<span class="text-xs text-slate-500 font-semibold shrink-0">配置目标</span>
|
|
<div class="flex gap-1.5 flex-wrap">
|
|
<button
|
|
v-for="d in deployments"
|
|
:key="d.id"
|
|
class="depchip"
|
|
:aria-pressed="curDep === d.id"
|
|
:title="d.status === 'pending' ? '待部署:业务库可能还未就绪' : ''"
|
|
@click="curDep = d.id"
|
|
>
|
|
<span class="dot" :class="{ pending: d.status === 'pending' }"></span>
|
|
{{ d.region || d.name || '部署#' + d.id }}
|
|
<span class="text-[10.5px] text-slate-400 font-mono">#{{ d.id }}</span>
|
|
</button>
|
|
<span v-if="!deployments.length" class="text-xs text-slate-400 py-1">该应用还没有部署,无处存放配置</span>
|
|
</div>
|
|
<span class="text-[11px] text-slate-400 ml-auto">配置存在该部署自己的业务库里,各区域互不影响</span>
|
|
</div>
|
|
|
|
<!-- 面板 -->
|
|
<AppDeployTab
|
|
v-show="tab === 'deps'"
|
|
ref="deployTab"
|
|
:app-name="appName"
|
|
:default-image="product.image_name || ''"
|
|
:deployments="deployments"
|
|
:envs="envs"
|
|
@refresh="loadAll"
|
|
@configure="onConfigure"
|
|
/>
|
|
<AppModuleTab v-if="tab === 'modules' && curDep" :key="'m' + curDep" :app-id="curDep" :dep-name="curDepName" />
|
|
<div v-if="tab === 'modules' && !curDep" class="bg-white rounded-xl border border-slate-100 py-16 text-center text-slate-400">
|
|
先在「部署环境」里创建一个部署,才能配置。
|
|
</div>
|
|
</template>
|
|
|
|
<!-- 编辑应用 -->
|
|
<dialog ref="appDlg" class="modal">
|
|
<div class="modal-box max-w-lg">
|
|
<h3 class="font-bold text-lg">编辑应用</h3>
|
|
<p class="text-xs text-slate-400 mt-1">
|
|
应用名是关联键:部署、账号作用域、第三方服务配置都按它挂载,改名会断开已有关联。
|
|
</p>
|
|
<form class="mt-4 space-y-3" @submit.prevent="saveProduct">
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-sm font-medium">应用名 *</span></label>
|
|
<input v-model="pForm.name" type="text" class="input input-bordered input-sm w-full" required />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-sm font-medium">默认镜像名</span></label>
|
|
<input v-model="pForm.image_name" type="text" class="input input-bordered input-sm w-full font-mono text-xs" />
|
|
</div>
|
|
<div class="form-control">
|
|
<label class="label py-1"><span class="label-text text-sm font-medium">备注</span></label>
|
|
<input v-model="pForm.remark" type="text" class="input input-bordered input-sm w-full" />
|
|
</div>
|
|
<label class="flex items-center gap-2 cursor-pointer pt-1">
|
|
<input v-model="pForm.enabled" type="checkbox" class="toggle toggle-primary toggle-sm" />
|
|
<span class="text-sm">启用</span>
|
|
</label>
|
|
<div class="modal-action">
|
|
<button type="button" class="btn btn-ghost btn-sm" @click="appDlg?.close()">取消</button>
|
|
<button type="submit" class="btn btn-primary btn-sm text-white">保存</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop"><button>close</button></form>
|
|
</dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
|
import { useApi } from '~/composables/useApi'
|
|
import { useToast } from '~/composables/useToast'
|
|
import type { Deployment, EnvOption, Product } from '~/types/app'
|
|
|
|
definePageMeta({ title: '应用详情' })
|
|
|
|
const { consoleApi } = useApi()
|
|
const { success, error } = useToast()
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
// vue-router 已经把 path 段解码过了,这里不能再 decode 一次:
|
|
// 应用名里带 % 时 decodeURIComponent 会直接抛 URIError。
|
|
const appName = computed(() => String(route.params.name || ''))
|
|
|
|
const product = ref<Product | null>(null)
|
|
const deployments = ref<Deployment[]>([])
|
|
const envs = ref<EnvOption[]>([])
|
|
const loading = ref(false)
|
|
|
|
const tab = ref<'deps' | 'modules'>('deps')
|
|
const curDep = ref(0)
|
|
const deployTab = ref<{ openNew: () => void } | null>(null)
|
|
|
|
const okCount = computed(() => deployments.value.filter((d) => d.status !== 'pending').length)
|
|
const pendingCount = computed(() => deployments.value.filter((d) => d.status === 'pending').length)
|
|
const curDepName = computed(() => {
|
|
const d = deployments.value.find((x) => x.id === curDep.value)
|
|
return d ? (d.name || '部署#' + d.id) : ''
|
|
})
|
|
const versionSpread = computed(() => {
|
|
const tags = [...new Set(deployments.value.filter((d) => d.status !== 'pending').map((d) => d.image_tag || 'latest'))]
|
|
return tags.length ? tags.join(' · ') : '—'
|
|
})
|
|
|
|
async function loadAll() {
|
|
loading.value = true
|
|
try {
|
|
const [ps, ds, es] = await Promise.all([
|
|
consoleApi<Product[]>('products/list', {}),
|
|
consoleApi<Deployment[]>('apps/list', {}),
|
|
consoleApi<EnvOption[]>('envs/list', {}),
|
|
])
|
|
product.value = (ps ?? []).find((p) => p.name === appName.value) ?? null
|
|
deployments.value = (ds ?? []).filter((d) => d.app_name === appName.value)
|
|
envs.value = es ?? []
|
|
// 默认配置目标:优先已接入的部署(待部署的业务库通常还连不上)
|
|
if (!deployments.value.some((d) => d.id === curDep.value)) {
|
|
const first = deployments.value.find((d) => d.status !== 'pending') ?? deployments.value[0]
|
|
curDep.value = first?.id ?? 0
|
|
}
|
|
} catch (e: any) {
|
|
error(e?.message ?? '加载应用详情失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// 从部署列表点「配置」:带着那个部署跳到业务功能配置
|
|
function onConfigure(id: number) {
|
|
curDep.value = id
|
|
tab.value = 'modules'
|
|
}
|
|
|
|
// ---- 编辑应用 ----
|
|
const appDlg = ref<HTMLDialogElement | null>(null)
|
|
const pForm = reactive({ id: 0, name: '', image_name: '', remark: '', enabled: true })
|
|
|
|
function openAppForm() {
|
|
const p = product.value
|
|
if (!p) return
|
|
pForm.id = p.id
|
|
pForm.name = p.name
|
|
pForm.image_name = p.image_name ?? ''
|
|
pForm.remark = p.remark ?? ''
|
|
pForm.enabled = !!p.enabled
|
|
appDlg.value?.showModal()
|
|
}
|
|
|
|
async function saveProduct() {
|
|
try {
|
|
const renamed = pForm.name !== appName.value
|
|
await consoleApi('products/update', {
|
|
id: pForm.id, name: pForm.name, image_name: pForm.image_name, remark: pForm.remark, enabled: pForm.enabled,
|
|
})
|
|
success('保存成功')
|
|
appDlg.value?.close()
|
|
// 改了应用名,当前 URL 已失效,跳到新名字下
|
|
if (renamed) router.replace(`/apps/${encodeURIComponent(pForm.name)}`)
|
|
else await loadAll()
|
|
} catch (e: any) {
|
|
error(e?.message ?? '保存失败')
|
|
}
|
|
}
|
|
|
|
watch(appName, loadAll)
|
|
onMounted(loadAll)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hero {
|
|
background: #fff; border: 1px solid #eef2f7; border-radius: 16px; padding: 20px 22px; margin-bottom: 16px;
|
|
box-shadow: 0 1px 2px rgba(15, 23, 42, .04), 0 4px 16px rgba(15, 23, 42, .03);
|
|
display: flex; gap: 18px; align-items: center; flex-wrap: wrap;
|
|
}
|
|
.hero-logo {
|
|
width: 58px; height: 58px; border-radius: 15px; flex-shrink: 0; color: #fff;
|
|
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
|
display: grid; place-items: center; font-weight: 800; font-size: 24px;
|
|
box-shadow: 0 8px 22px rgba(79, 70, 229, .3);
|
|
}
|
|
.hero-name { font-size: 20px; font-weight: 700; color: #1e293b; letter-spacing: -.015em; display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
|
|
|
|
.kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 18px; }
|
|
.kpi {
|
|
background: #fff; border: 1px solid #eef2f7; border-radius: 12px; padding: 13px 15px;
|
|
position: relative; overflow: hidden; box-shadow: 0 1px 2px rgba(15, 23, 42, .04);
|
|
}
|
|
.kpi::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: #e2e8f0; }
|
|
.kpi.ok::before { background: #16a34a; }
|
|
.kpi.warn::before { background: #d97706; }
|
|
.kpi.accent::before { background: #4f46e5; }
|
|
.kpi-l { font-size: 10.5px; color: #94a3b8; letter-spacing: .04em; text-transform: uppercase; font-weight: 600; }
|
|
.kpi-v { font-size: 22px; font-weight: 700; color: #1e293b; margin-top: 3px; letter-spacing: -.02em; font-family: 'SF Mono', Menlo, monospace; font-variant-numeric: tabular-nums; }
|
|
.kpi-v small { font-size: 12px; color: #94a3b8; font-weight: 500; margin-left: 3px; }
|
|
|
|
.tablist { display: flex; gap: 2px; border-bottom: 1px solid #e2e8f0; margin-bottom: 18px; overflow-x: auto; }
|
|
.tab {
|
|
border: 0; background: none; font-size: 13.5px; color: #64748b; padding: 10px 15px; cursor: pointer;
|
|
border-bottom: 2px solid transparent; margin-bottom: -1px; white-space: nowrap; display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.tab:hover { color: #1e293b; }
|
|
.tab[aria-selected='true'] { color: #4338ca; border-bottom-color: #4f46e5; font-weight: 600; }
|
|
.tab-n { font-size: 10.5px; background: #f8fafc; border: 1px solid #eef2f7; color: #64748b; border-radius: 20px; padding: 0 6px; font-family: 'SF Mono', Menlo, monospace; }
|
|
.tab[aria-selected='true'] .tab-n { background: #eef2ff; color: #4338ca; border-color: transparent; }
|
|
|
|
.depbar {
|
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
|
background: #f8fafc; border: 1px solid #eef2f7; border-radius: 11px; padding: 10px 13px; margin-bottom: 16px;
|
|
}
|
|
.depchip {
|
|
border: 1px solid #e2e8f0; background: #fff; border-radius: 20px; padding: 4px 12px; font-size: 12px;
|
|
cursor: pointer; color: #334155; display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.depchip:hover { border-color: #0e7490; }
|
|
.depchip[aria-pressed='true'] { background: #ecfeff; border-color: #0e7490; color: #0e7490; font-weight: 600; }
|
|
.depchip .dot { width: 6px; height: 6px; border-radius: 50%; background: #16a34a; }
|
|
.depchip .dot.pending { background: #d97706; }
|
|
|
|
.chip { display: inline-flex; align-items: center; font-size: 10.5px; font-weight: 700; padding: 2px 7px; border-radius: 5px; background: #f1f5f9; color: #64748b; }
|
|
.chip-ok { background: #dcfce7; color: #15803d; }
|
|
.chip-mono { font-family: 'SF Mono', Menlo, monospace; font-weight: 600; }
|
|
</style>
|
|
|